Skip to content

Cut monitor-node CPU: raw health subs, idle-aware task tick - #96

Merged
MJohnson459 merged 1 commit into
mainfrom
monitor-cpu-tuning
Aug 12, 2026
Merged

Cut monitor-node CPU: raw health subs, idle-aware task tick#96
MJohnson459 merged 1 commit into
mainfrom
monitor-cpu-tuning

Conversation

@MJohnson459

Copy link
Copy Markdown
Contributor

Both sanctioned Python fixes landed, measured on mote-01, and the C++ decision is
taken on evidence — but the evidence is not what the task expected, and the ~5%
target is not reachable in Python. Branch monitor-cpu-tuning, commit d588d08,
unpushed. Write-up: docs/tuning/2026-08-11-monitor-cpu.md with raw sampler
output beside it.

What changed

  • health_monitor — the four watched topics are subscribed raw=True. The
    watches only count arrivals and never read a field. /diagnostics stays
    deserialized: it reads status.name/status.level.
  • task_server — new idle_tick_period parameter (default 1.0). The tree ticks
    at tick_period only while a task runs. _set_tick_rate resets the timer
    as well as re-periodding it, because setting a period does not move the expiry
    already pending — without the reset the first tick of an accepted tree (the one
    that sends the Nav2 goal) waits out the rest of the idle period, measured at
    2.00 s.
  • mote_bringup/tools/node_cpu.py (pixi run node-cpu) — the per-node sampler
    the measurements were taken with, kept so they can be re-derived.
  • Tests: test_health_monitor_node.py (new — drives the real node and asserts
    the watched topics are raw, /diagnostics is not, and a raw scan still
    reaches /health and /diagnostics_agg), plus two cases in
    test_goto_tree.py for the tick rates. docs/tuning doc, mkdocs nav entry,
    CLAUDE.md note.

Results (paired, both builds running at the same instant on mote-01)

node before after
health_monitor 18.1% 17.1%
task_server 7.8% 6.9%

The acceptance target of ~5% is not met, and cannot be met in Python. The
task's root-cause hypothesis was that these nodes pay to deserialize. Measured:
deserialization is 13% of what a subscription costs; the rclpy wake-up is the
other 87%, at ~0.78 ms of CPU per message delivered to a callback that increments
a counter. Decomposition (four probes, same live graph): bare rclpy node 0.5%;
+7.9 points for 4 subscriptions carrying 101 msg/s; +1.2 for deserializing them;
+4.8 for a single TransformListener on the 51 Hz /tf. health_monitor
consumes ~152 msg/s, so ~12 points are gone before it does anything.

Two corrections to the brief, both load-bearing:

  • The TF listener is the biggest single cost, not a footnote. The brief ruled
    it out ("localization is severity: info and low-rate, so it is not the
    cost"); a listener takes the whole /tf stream regardless of which edges you
    ask about. It is also most of what task_server spends while idle, through the
    listener AcquireObject creates in setup() and uses only during a fetch.
  • Ticking was never task_server's cost. Dropping 10 Hz → 1 Hz removes 90%
    of the ticks and 12% of the node's CPU. The change is still right (free, and an
    idling tree has nothing to advance) but it is not the lever the figure implied.
  • Stage-1 item 3 (swap the odometry TF watch for a topic watch) was measured
    and rejected: both candidate topics are ~50 Hz, so it buys no wake-ups, and
    a fresh topic is not evidence the TF edge Nav2 consumes was broadcast.

Verification

  • 418 passed, 2 skipped across mote_bringup/test + mote_tasks/test; the
    five mote_fleet e2e tests that construct TaskServer pass against a real
    mosquitto. pre-commit run --all-files clean.
  • The timer-reset test was negative-controlled: with reset() removed it fails
    with "the accepted tree waits 2.00s for its first tick".
  • /health and /diagnostics_agg unchanged in content and cadence (1.0 Hz,
    data: OK, mote roll-up first then one status per subsystem); the systemd
    watchdog is still petted on every publish; command acceptance is unaffected
    (it happens in the subscription callback, not on a tick).

Follow-ups filed

  • 381 — port health_monitor to C++. Justified by the numbers, and specced
    with the correction above (it must own /tf too).
  • 382slip_monitor is now the largest consumer at 7.1%. Not portable as
    it stands: its maths is shared with slip_replay.py, which is what calibrated
    slip.yaml, so the shared-maths problem has to be answered first.

Two things to know

  • The robot's servo bus is intermittently failing. MoteHardware logs
    Failed to read position from servo 7/9 in bursts; the 50 Hz control loop
    collapses to ~1.6 Hz, /joint_states to 1.6 Hz and /tf to 33 Hz. It
    recovered mid-session (3159 read failures in one run, 564 in the next). Arm
    servos answered throughout, so it looks like the wheel servos specifically —
    worth a look at the wiring. This is also why every result here is paired: a
    sequential before/after over that drift reported the patch making everything
    worse, including slip_monitor, which was not touched.
  • The under-mission measurement was not taken. I asked before dispatching a
    goto (it means the robot drives itself) and had no answer, so the figures are
    stack-up-idle with a synthetic 50 Hz /joint_states standing in for a healthy
    control loop. The acceptance criterion is an idle one, and the paired design
    makes the comparison sound regardless — but if you want the loaded numbers, say
    so and it is one more run.

The Pi was left as found: stack down, scratch removed, its checkout untouched and
clean, mote-agent still active. Its code was never modified — the patched
modules reached it through a scratch PYTHONPATH.

health_monitor and task_server were measured at 22% and 14-20% of a core on
mote-01 during a nav mission, for logic that runs at 1-10 Hz. The premise was
that they pay to deserialize high-rate messages. They do not, much: measured on
the robot, deserialization is 13% of what a subscription costs and the rclpy
wake-up is the other 87%.

Both sanctioned Python fixes land, because both are free and both are real,
paired against the unpatched build running at the same instant:

  health_monitor  raw=True on the watched topics  18.1 -> 17.1
  task_server     idle tick 10 Hz -> 1 Hz          7.8 ->  6.9

The watches only count arrivals and never read a field, so they take bytes; the
/diagnostics subscription stays deserialized because it reads status.name and
status.level. A tree between missions ticks WaitForTask and nothing else, so it
ticks at idle_tick_period until a command is accepted -- and _set_tick_rate
resets the timer as well as re-periodding it, because setting a period does not
move the expiry already pending, so without the reset the first tick of an
accepted tree (the one that sends the Nav2 goal) waits out the rest of the idle
period. Measured at 2.00 s, and held by a test.

Two things had to be got right before any figure meant anything, and both are
in tools/node_cpu.py, the sampler this leaves behind. The robot's own condition
drifts in exactly the variable under study -- the drive servos answer
intermittently, and a run where /tf was 33 Hz against one where it was 51 Hz
reports the patch making everything worse, including slip_monitor, which was
not touched. So a node is identified by the entry point its interpreter runs
plus any __node:= rename, which lets two builds of one node be sampled against
each other in a single run, and keeps the ros2 run and pixi run wrappers -- one
of which does no work at all -- from being weighed instead of the node.

The ~5% target is not reachable in Python and the C++ port of health_monitor is
justified, on evidence the original framing did not have: a bare rclpy node is
free (0.5%), four subscriptions carrying 101 msg/s cost 7.9 points more, and a
TransformListener costs 4.8 for one 51 Hz stream -- the most expensive thing
either node holds, and the bulk of what task_server spends while idle, through
the listener AcquireObject creates in setup() and uses only during a fetch.
health_monitor consumes ~152 msg/s, so ~12 points are gone before it does
anything with them. Swapping the odometry TF watch for a topic watch buys
nothing, both candidates being ~50 Hz. Rationale, rejected alternatives and
what the port must be are in docs/tuning/2026-08-11-monitor-cpu.md, with the
raw sampler output beside it.

slip_monitor is now the largest remaining consumer at 7.1 and is filed
separately: its maths is shared with tools/slip_replay.py, which is what set
config/slip.yaml's thresholds, so a port forks that and invalidates the
calibration.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X6o6x5Nn7QYUMxg2F3Wy2t
Comment on lines +9 to +10
python -m mote_bringup.tools.node_cpu --duration 60 --tag idle \
--out docs/tuning/2026-08-11-monitor-cpu/before

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

python -m mote_bringup.tools.node_cpu ... won't actually work: mote_bringup/tools/ has no __init__.py and isn't discovered by setup.py's find_packages(), so mote_bringup.tools isn't an importable module (running this raises No module named mote_bringup.tools). The same invalid form is repeated at line 34.

The working invocation — matching the node-cpu pixi task this PR adds (pixi.toml: node-cpu = "python mote_bringup/tools/node_cpu.py") — is:

python mote_bringup/tools/node_cpu.py --duration 60 --tag idle \
    --out docs/tuning/2026-08-11-monitor-cpu/before

(and similarly for the --nodes example at line 34).

@MJohnson459
MJohnson459 merged commit 32294db into main Aug 12, 2026
7 checks passed
@MJohnson459
MJohnson459 deleted the monitor-cpu-tuning branch August 12, 2026 10:15
MJohnson459 added a commit that referenced this pull request Aug 12, 2026
`python -m mote_bringup.tools.node_cpu` raises `ModuleNotFoundError: No module
named 'mote_bringup.tools'`. `mote_bringup/tools/` has no `__init__.py`, so
setup.py's `find_packages()` never picks it up -- deliberately, since these are
harnesses run from a checkout and not code the robot installs. Every sibling
tool documents the path form for that reason; node_cpu was the only one
claiming otherwise, in its two usage examples and in the tuning run notes.

Use `pixi run node-cpu`, which is the task this tool already ships with and the
form camera_layer_decay documents (pixi appends trailing arguments, so
`--summary`/`--nodes` pass straight through). Reported by review on #96.


Claude-Session: https://claude.ai/code/session_01X6o6x5Nn7QYUMxg2F3Wy2t

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant